Skip to content

Conversation

@sm-sayedi
Copy link
Collaborator

@sm-sayedi sm-sayedi commented Oct 8, 2025

Fixes-partly: #124 (topic link autocomplete will be its own PR)

Screenshots
Empty query A query written An option chosen
1  Empty query 2  Query written 3  Option chosen
Autocompleting channels with problematic characters Channel with problematic characters chosen Message sent
4  Autocompleting with problematic characters 5  Channel with problematic characters chosen 6  Message sent
Screen recording
Channel.autocomplete.recording.mov

@gnprice
Copy link
Member

gnprice commented Oct 9, 2025

Exciting! Thanks for building this.

Just to record here what I said on the team call yesterday: for this PR, we can start the reviews in parallel with you writing the tests. So I'd suggest going ahead and adding those docs and comments next — then once you consider the PR all ready except for the tests, just mention that here and add the "maintainer review" label.

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 5a8171d to 6671cfa Compare October 9, 2025 21:00
@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for mentioning this. This is now ready for an initial review. (While working on the first todo, I realized that there were a few other places that needed some changes, which caused a delay 😀)

@sm-sayedi sm-sayedi marked this pull request as ready for review October 9, 2025 21:07
@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Oct 9, 2025
@sm-sayedi sm-sayedi requested a review from chrisbobbe October 9, 2025 21:07
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 6671cfa to fe25a32 Compare October 10, 2025 05:28
@sm-sayedi
Copy link
Collaborator Author

(just rebased atop main with conflicts resolved)

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from fe25a32 to 480e787 Compare October 11, 2025 12:00
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch 2 times, most recently from 6b2fb06 to 5072dce Compare October 21, 2025 19:38
@sm-sayedi
Copy link
Collaborator Author

@chrisbobbe Pushed a new revision with tests included.

@chrisbobbe
Copy link
Collaborator

Thanks for this, and apologies for my delay in reviewing! Here's a review of the first six commits:

05c8437 channel: Add isRecentlyActive field
5a6b96a api: Update ChannelDeleteEvent to match new API changes
607dc8b basic: Add tryCast method
c9d282e store: Call AutocompleteViewManager.handleUserGroupRemove/UpdateEvent
c5f6c77 autocomplete [nfc]: Remove User(Group) params from _rankUser(Group)Result
a459122 autocomplete [nfc]: Move _matchName up to AutocompleteQuery

plus some comments on later commits where I happened to see something interesting. 🙂

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh no—thanks for the ping in DMs, somehow I didn't actually submit that review yesterday! Here it is.

historyPublicToSubscribers: historyPublicToSubscribers ?? true,
messageRetentionDays: messageRetentionDays,
channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any,
isRecentlyActive: isRecentlyActive ?? false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think true might be a more natural default value for this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to true. The false default value was to make the tests in "ranking across signals" and "final results end-to-end" of "ChannelLinkAutocompleteView" group a little less verbose.🙂

@JsonKey(name: 'stream_post_policy')
ChannelPostPolicy? channelPostPolicy; // TODO(server-10) remove
// final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore
bool? isRecentlyActive; // TODO(server-10)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channel: Add isRecentlyActive field

Since we're already not matching the order in the API doc (see e.g. #1894 (comment) ), I'd put this next to the related-looking field streamWeeklyTraffic, perhaps just above it without an empty line in between.

Similarly elsewhere in this commit.

required super.id,
required this.streams,
required this.streamIds,
}) : assert(streams != null || streamIds != null);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try to reserve assert for invariants that are our own responsibility, i.e. those that won't be broken except for some broken logic in the client. Here, the invariant exists, but it's one that can be broken by something out of our control, in particular a badly-behaving server.

Also, asserts don't run in production, so this won't work as "crunchy shell" validation. It makes sense to want such validation, so the "soft center" of the app can rely on this invariant. But let's do it in ChannelDeleteEvent.fromJson; for an example to follow, see DeleteMessageEvent.fromJson.


final List<ZulipStream> streams;
final List<ZulipStreamId>? streams; // TODO(server-10): remove
final List<int>? streamIds; // TODO(server-10): remove nullability
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we'd normally say TODO(server-10) make required or just TODO(server-10).

streams.remove(stream.streamId);
streamsByName.remove(stream.name);
subscriptions.remove(stream.streamId);
final channelIds = event.streamIds ?? event.streams!.map((e) => e.streamId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh actually, we can make this nicer by encapsulating this conditional in the API-binding layer—ChannelDeleteEvent can have just final List<int> streamIds (maybe channelIds, as the more modern name?), and it can read its value depending on what the JSON looks like.

Something like this (untested)?:

/// A [ChannelEvent] with op `delete`: https://zulip.com/api/get-events#stream-delete
@JsonSerializable(fieldRename: FieldRename.snake)
class ChannelDeleteEvent extends ChannelEvent {
  @override
  @JsonKey(includeToJson: true)
  String get op => 'delete';

  @JsonKey(readValue: _readChannelIds)
  final List<int> channelIds;

  // TODO(server-10) simplify away; rely on stream_ids
  static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
    final channelIds = json['stream_ids'] as List<int>?;
    if (channelIds != null) return channelIds;

    final channels = json['streams'] as List<dynamic>;
    return channels
      .map((c) => (c as Map<String, dynamic>)['stream_id'] as int)
      .toList();
  }

  ChannelDeleteEvent({
    required super.id,
    required this.channelIds,
  });

  factory ChannelDeleteEvent.fromJson(Map<String, dynamic> json) =>
    _$ChannelDeleteEventFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$ChannelDeleteEventToJson(this);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(In that code, the crunchy-shell validation is done by the final channels = json['streams'] as List<dynamic>; line, which will throw if both .stream_ids and .streams are absent in the json.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to the new version. One thing that this does is that in toJson, there will be an entry with key channel_ids; not exactly what the server gives us (stream_ids or streams). Should we edit the toJson method to match the server data, or is it not that important since we don't use it to send it back to the server?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, so there was a test failing in test/model/store_test.dart and the fix was to include streams in toJson.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, streams is deprecated and may be removed, so I think we should treat it as valid if streams is absent and stream_ids is present. I'd want our tests to tolerate that form without failing.

I took a look and found a bug in _readChannelIds in this revision 🙂:

diff --git lib/api/model/events.dart lib/api/model/events.dart
index 6a0d9ffa4..4d9c5121c 100644
--- lib/api/model/events.dart
+++ lib/api/model/events.dart
@@ -622,7 +622,7 @@ class ChannelDeleteEvent extends ChannelEvent {
   // TODO(server-10) simplify away; rely on stream_ids
   static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
     final channelIds = json['stream_ids'] as List<dynamic>?;
-    if (channelIds != null) channelIds.map((id) => id as int).toList();
+    if (channelIds != null) return channelIds.map((id) => id as int).toList();
 
     final channels = json['streams'] as List<dynamic>;
     return channels

overflow: TextOverflow.ellipsis,
color: designVariables.contextMenuItemMeta)),
child: BlockContentList(
nodes: parseContent(channel!.renderedDescription).nodes),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! Unfortunately we're not ready to show the rendered channel description; some of our content widgets will break if they appear outside the context of a message, because they use InheritedMessage.of(context), and we need to address that systematically, which is #488. See related issues:

For now let's do as I did in #1877:

  • Not try to show the channel description here
  • File an issue for it and leave a TODO

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed #1945. Also, looking at https://github.com/zulip/zulip-flutter/blob/main/lib/widgets/content.dart, it seems like InheritedMessage.of(context) is used in two places, MessageImagePreview and MessageInlineVideo, and by manually testing, it seems like the server is not sending the related HTML for rendering these widgets when there is an image or video in the channel description. So I think it will be safe to show the channel description now. But as it’s possible that InheritedMessage.of(context) will be used in other widgets, it's good to wait for #488 as you mentioned.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by manually testing, it seems like the server is not sending the related HTML for rendering these widgets when there is an image or video in the channel description. So I think it will be safe to show the channel description now.

For this sort of detail I'd want to rely on API guarantees rather than manual testing on a current server. It's at least plausible that we could run into different behavior with some old server we still support (like 7), or even on any server, including a modern one like CZO, for a channel that was last updated when the server version was ancient, like 3 or something.

But as it’s possible that InheritedMessage.of(context) will be used in other widgets, it's good to wait for #488 as you mentioned.

Yep, this is also true :) it'll be nice to be systematic about it.

Comment on lines +1536 to +1573
// Behavior we have that web doesn't and might like to follow:
// - A "word-prefixes" match quality on channel names:
// see [NameMatchQuality.wordPrefixes], which we rank on.
//
// Behavior web has that seems undesired, which we don't plan to follow:
// - A "word-boundary" match quality on channel names:
// special rank when the whole query appears contiguously
// right after a word-boundary character.
// Our [NameMatchQuality.wordPrefixes] seems smarter.
// - Ranking some case-sensitive matches differently from case-insensitive
// matches. Users will expect a lowercase query to be adequate.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I reading web's sort_streams correctly that it also considers channel descriptions in the filtering and ranking? I don't personally think we need to do that, but it probably deserves a mention here.

Comment on lines 1439 to 1447
return switch((tryCast<Subscription>(a), tryCast<Subscription>(b))) {
(Subscription(), null) => -1,
(null, Subscription()) => 1,
(Subscription(isMuted: false), Subscription(isMuted: true)) => -1,
(Subscription(isMuted: true), Subscription(isMuted: false)) => 1,
(Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1,
(Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1,
_ => 0,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer not to add and use tryCast for this, and instead do something like:

Suggested change
return switch((tryCast<Subscription>(a), tryCast<Subscription>(b))) {
(Subscription(), null) => -1,
(null, Subscription()) => 1,
(Subscription(isMuted: false), Subscription(isMuted: true)) => -1,
(Subscription(isMuted: true), Subscription(isMuted: false)) => 1,
(Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1,
(Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1,
_ => 0,
};
if (a is Subscription && b is! Subscription) return -1;
if (a is! Subscription && b is Subscription) return 1;
return switch((a, b)) {
(Subscription(isMuted: false), Subscription(isMuted: true)) => -1,
(Subscription(isMuted: true), Subscription(isMuted: false)) => 1,
(Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1,
(Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1,
_ => 0,
};

which is equivalent and doesn't add a step for the reader to interpret where null comes from and what it means. It also separates the main, headline logic from the rest, corresponding to the dartdoc's choice of what goes in its first line vs. the body:

  /// Comparator that puts subscribed channels before unsubscribed ones.
  ///
  /// For subscribed channels, it puts them in the following way:
  ///   pinned unmuted > unpinned unmuted > pinned muted > unpinned muted

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also nit: s/way/order/ in that dartdoc)

Comment on lines 1466 to 1472
/// Comparator that puts channels with more weekly traffic first.
///
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).
///
/// Weekly traffic is the average number of messages sent to the channel per
/// week, which is determined by [ZulipStream.streamWeeklyTraffic].
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Comparator that puts channels with more weekly traffic first.
///
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).
///
/// Weekly traffic is the average number of messages sent to the channel per
/// week, which is determined by [ZulipStream.streamWeeklyTraffic].
/// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first.
///
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).

This is a very reasonable definition of "weekly traffic" 🙂 and so isn't likely to bitrot i.e. become incorrect over time. But since we're just using ZulipStream.streamWeeklyTraffic directly (no computations on it), let's leave that field's definition as the single place where we write its definition, so we only have to change that one thing if the meaning changes.

…I see that we haven't actually written down the field's meaning, which we might've done in dartdoc on the field. But that's quite normal and appropriate; by leaving it blank, we mean to defer to the API documentation, which is linked in the class ZulipStream dartdoc.

eg.stream(streamId: 5, name: 'UI [v2]'),
eg.stream(streamId: 6, name: r'Save $$'),
];
store.addStreams(channels);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be awaited; similarly in a few other places.

Thanks to the discarded_futures lint for catching this, actually; I was playing with it for #731 this morning 🙂

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch 2 times, most recently from 8cde339 to ea64c45 Compare October 24, 2025 19:42
@sm-sayedi
Copy link
Collaborator Author

Thanks @chrisbobbe for the review. Pushed new changes, PTAL.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! This is working great; comments below, this time from reading the whole branch.

Comment on lines 686 to 687
case ChannelPropertyName.isRecentlyActive:
return value as bool?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: bump on #1902 (comment)

Comment on lines 1269 to 1270
case ChannelPropertyName.isRecentlyActive:
assert(value is bool?);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: bump on #1902 (comment)

streams.remove(stream.streamId);
streamsByName.remove(stream.name);
subscriptions.remove(stream.streamId);
final channelIds = event.streamIds ?? event.streams!.map((e) => e.streamId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, streams is deprecated and may be removed, so I think we should treat it as valid if streams is absent and stream_ids is present. I'd want our tests to tolerate that form without failing.

I took a look and found a bug in _readChannelIds in this revision 🙂:

diff --git lib/api/model/events.dart lib/api/model/events.dart
index 6a0d9ffa4..4d9c5121c 100644
--- lib/api/model/events.dart
+++ lib/api/model/events.dart
@@ -622,7 +622,7 @@ class ChannelDeleteEvent extends ChannelEvent {
   // TODO(server-10) simplify away; rely on stream_ids
   static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
     final channelIds = json['stream_ids'] as List<dynamic>?;
-    if (channelIds != null) channelIds.map((id) => id as int).toList();
+    if (channelIds != null) return channelIds.map((id) => id as int).toList();
 
     final channels = json['streams'] as List<dynamic>;
     return channels

for (final view in _channelLinkAutocompleteViews) {
view.reassemble();
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! Sounds like that would help us avoid a bug like the one fixed here.

// Similar reasoning as in _mentionIntentRegex.
const before = r'(?<=^|\s|\p{Punctuation})';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also could you add test cases for the strings "#124"

Ah! It seems GitHub mangled my comment: I wrote zulip/zulip-flutter#124, but it linkified it the same way as it linkifies #124, because this is a PR in zulip/zulip-flutter.

Comment on lines +184 to +212
// As Web, match both '#channel' and '#**channel'. In both cases, the raw
// query is going to be 'channel'. Matching the second case ('#**channel')
// is useful when the user selects a channel from the autocomplete list, but
// then starts pressing "backspace" to edit the query and choose another
// option, instead of clearing the entire query and starting from scratch.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Looks like web also does the corresponding thing for @-mentions: https://github.com/zulip/zulip/blob/1e78447c5/web/src/composebox_typeahead.ts#L516-L531

function filter_mention_name(current_token: string): string | undefined {
    if (current_token.startsWith("**")) {
        current_token = current_token.slice(2);
    } else if (current_token.startsWith("*")) {
        current_token = current_token.slice(1);
    }
    if (current_token.includes("*")) {
        return undefined;
    }

    // Don't autocomplete if there is a space following an '@'
    if (current_token.startsWith(" ")) {
        return undefined;
    }
    return current_token;
}

This is maybe more important for channel/topic autocomplete, right? Because (once the topic part is implemented) you might backspace as part of figuring out how to get just a channel link without a topic. But this is a good prompt to file an issue and add a TODO for doing this with @-mention autocomplete; would you do those?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that would be an improvement. I have always missed that feature for @-mentions; filed #1967.

} else {
icon = null;
iconColor = null;
label = zulipLocalizations.unknownChannelName;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we normally show "(unknown channel)" in italics, to distinguish it from a potential channel with that name.

Comment on lines 408 to 411
padding: EdgeInsetsDirectional.fromSTEB(12, 4, 10, 4),
child: Row(spacing: 10, children: [
SizedBox.square(dimension: 24,
child: Icon(size: 18, color: iconColor, icon)),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rows have 44px height in the Figma, but this gives 32px height (unless increased via the device text-size setting).

To bring it up to 44px, we could structure it similarly to MentionAutocompleteItem

Suggested change
padding: EdgeInsetsDirectional.fromSTEB(12, 4, 10, 4),
child: Row(spacing: 10, children: [
SizedBox.square(dimension: 24,
child: Icon(size: 18, color: iconColor, icon)),
padding: EdgeInsetsDirectional.fromSTEB(4, 4, 8, 4),
child: Row(spacing: 6, children: [
SizedBox.square(dimension: 36,
child: Center(
child: Icon(size: 18, color: iconColor, icon))),

—which could be helpful in a potential future where we made a generic AutocompleteItem widget that serves both kinds of autocomplete.

///
/// [fallbackMarkdownLink] will be used if the channel name includes some faulty
/// characters that will break normal #**channel** rendering.
String channelLinkSyntax(ZulipStream channel, {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just channelLink, I think; the "syntax" part is implied. (As in existing functions in this file, like quoteAndReply which creates quote-and-reply syntax, or userMention which creates user-mention syntax.)

Comment on lines +203 to +210
/// Markdown link for channel, topic, or message when the channel or topic name
/// includes characters that will break normal markdown rendering.
///
/// Refer to [_channelTopicFaultyCharsReplacements] for a complete list of
/// these characters.
// Corresponds to `topic_link_util.get_fallback_markdown_link` in Zulip web;
// https://github.com/zulip/zulip/blob/b42d3e77e/web/src/topic_link_util.ts#L96-L108
String fallbackMarkdownLink({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channel: Finish channel link autocomplete for compose box

Could you separate this special character-replacement logic into its own commit? I want to see if we can ground our reasoning in API documentation. As far as that goes, there's nothing "invalid" or "faulty" about these characters appearing in channel names.

@sm-sayedi
Copy link
Collaborator Author

sm-sayedi commented Nov 3, 2025

Thanks @chrisbobbe for the previous review. Pushed a new revision, with some new commits added, PTAL.

65ad611 api: Add InitialSnapshot.maxChannelNameLength
3db4161 realm: Add RealmStore.maxChannelNameLength
442054d autocomplete: Introduce AutocompleteViewManager._autocompleteViews
a6caa11 compose: Introduce PerAccountStore in ComposeController

(The media in the PR description has also been updated.)

@sm-sayedi sm-sayedi requested a review from chrisbobbe November 3, 2025 10:13
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from acb6615 to 513bc80 Compare November 3, 2025 15:43
@chrisbobbe
Copy link
Collaborator

Ah I see this has gathered some conflicts; would you resolve those please?

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 513bc80 to 6d28107 Compare November 4, 2025 05:04
@chrisbobbe chrisbobbe added the integration review Added by maintainers when PR may be ready for integration label Nov 10, 2025
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @sm-sayedi for building this, and @chrisbobbe for the previous reviews!

Here's a review of the first 10 commits:
fac4457 api: Add InitialSnapshot.maxChannelNameLength
fd4d629 realm: Add RealmStore.maxChannelNameLength
ca7414b api: Add ZulipStream.isRecentlyActive
5b7d81b api: Update ChannelDeleteEvent to match new API changes
5563131 autocomplete: Call EmojiAutocompleteView.reassemble where needed
89a8a40 emoji: Add EmojiAutocompleteView.dispose
b7c5278 autocomplete [nfc]: Introduce AutocompleteViewManager._autocompleteViews
9696df9 store: Call AutocompleteViewManager.handleUserGroupRemove/UpdateEvent
994944e autocomplete [nfc]: Move _matchName up to AutocompleteQuery
64d68a0 autocomplete: Add view-model ChannelLinkAutocompleteView

except for the last one's tests. Still ahead are those tests and the remaining 8 commits:
3543b71 autocomplete test [nfc]: Use MarkedTextParse as the return type of parseMarkedText
1a9bbf6 compose: Introduce PerAccountStore in ComposeController
267f7b0 autocomplete: Identify when the user intends a channel link autocomplete
b58f34c autocomplete [nfc]: Add a TODO(#1967) for ignoring starting "**" after "#"
5510c30 autocomplete test: Make setupToComposeInput accept channels param
a6d0938 internal_link [nfc]: Factor out constructing fragment in its own method
831ccf1 compose: Introduce fallbackMarkdownLink function
b654dd1 channel: Finish channel link autocomplete for compose box

Comment on lines 706 to 707
case ChannelPropertyName.isRecentlyActive:
return value as bool?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be just bool, right? This property will be a bool when it exists at all.

Comment on lines 402 to 409
final channel = streams[channelId];
if (channel == null) break;
assert(identical(streams[channel.streamId], streamsByName[channel.name]));
assert(subscriptions[channelId] == null
|| identical(subscriptions[channelId], streams[channelId]));
streams.remove(channel.streamId);
streamsByName.remove(channel.name);
subscriptions.remove(channel.streamId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kind of confusing how this alternates between channelId and channel.streamId. Those should be equal. It'd therefore be clearer to assert those are equal, and then use channelId throughout.

Comment on lines 402 to 406
final channel = streams[channelId];
if (channel == null) break;
assert(identical(streams[channel.streamId], streamsByName[channel.name]));
assert(subscriptions[channelId] == null
|| identical(subscriptions[channelId], streams[channelId]));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, since this now looks up streams[channelId] up front, it can skip repeating that lookup in these asserts.

Comment on lines 402 to 407
final channel = streams[channelId];
if (channel == null) break;
assert(identical(streams[channel.streamId], streamsByName[channel.name]));
assert(subscriptions[channelId] == null
|| identical(subscriptions[channelId], streams[channelId]));
streams.remove(channel.streamId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than look up the channel by ID once, then look it up again as part of remove, this can call remove up front and keep the return value.

(The existing code has asserts that lack that kind of optimization — but they're asserts, so only run in debug builds anyway and optimization is less of a concern.)

Comment on lines 1362 to 1368
final channelId = switch (narrow) {
ChannelNarrow(:var streamId) || TopicNarrow(:var streamId) => streamId,
DmNarrow() => null,
CombinedFeedNarrow()
|| MentionsNarrow()
|| StarredMessagesNarrow()
|| KeywordSearchNarrow() => () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think a switch statement (like in MentionAutocompleteView) would be easier to read here.

One reason is that the formatting would be more consistent that way:

  • each case gets one line (vs. here where TopicNarrow appears off on the right);
  • each outcome starts at the same column (vs. here where streamId, null, and the IIFE all start in quite different places).

The switch statement would also simplify diffs when adding (or removing) subclasses for Narrow: just insert (or remove) lines case FooNarrow:, vs. here where existing lines might need to be edited to add or remove a || prefix or => () { suffix. Put another way: it's helpful when the different items in a list are all formatted the same, vs. the first or last items being formatted differently.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, make sense.

Comment on lines 1449 to 1450
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that behavior desired?

From the code at your handy link above (https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988), it looks like web treats unknown weekly traffic equal to zero.

From the API docs for stream_weekly_traffic:

If null, the channel was recently created and there is insufficient data to estimate the average traffic.

That seems like it should rank at least as high as a value of zero.

So probably we should just match the web behavior.

Comment on lines 1407 to 1409
return switch ((a.streamId, b.streamId)) {
(int id, _) when id == composingToChannelId => -1,
(_, int id) when id == composingToChannelId => 1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When both channels match, this gives -1 but should give 0.

Comment on lines 1436 to 1460
static int compareByRecentActivity(ZulipStream a, ZulipStream b) {
return switch((a.isRecentlyActive, b.isRecentlyActive)) {
(true, false) => -1,
(false, true) => 1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The corresponding logic in web seems to be a bit more complicated, in stream_list_sort.ts:

export function has_recent_activity(sub: StreamSubscription): boolean {
    if (!filter_out_inactives || sub.pin_to_top) {
        // If users don't want to filter inactive streams
        // to the bottom, we respect that setting and don't
        // treat any streams as dormant.
        //
        // Currently this setting is automatically determined
        // by the number of streams.  See the callers
        // to set_filter_out_inactives.
        return true;
    }
    return sub.is_recently_active || sub.newly_subscribed;

Can you explain why we don't match those other wrinkles?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason for not including other criteria was that we don't have all the information. For example, sub.newly_subscribed. We already favor pinned channels (sub.pin_to_top). And for filter_out_inactives, I think we can know about it by getting the value for demote_inactive_streams in UserSettings, but I thought it would be simpler to ingore it for now. But if we want to use it, I'll be glad to add it in the next revision. 🙂

(Added some comments that briefly mention this difference.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to add those in this PR 🙂.

I'd like to have the complete list of differences in a comment in the code, though — or at least the full list of what you're aware of.

Comment on lines 1376 to 1380
// Check `typeahead_helper.compare_by_activity` in Zulip web;
// We follow the behavior of Web but with a small difference in that Web
// compares "recent activity" only for subscribed channels, but we do it
// for unsubscribed ones too.
// https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
static int _compareByRelevance(ZulipStream a, ZulipStream b, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like an implementation comment, so let's put it inside the method's body.

For an example of how to present a (more complex) comparison with web's behavior, see EmojiAutocompleteView._rankResult. That might in particular be helpful when adding an answer to my question about compareByRecentActivity vs. stream_list_sort.has_recent_activity.

Comment on lines +1552 to +1578
// - Matching and ranking on channel descriptions but only when the query
// is present (but not an exact match, total-prefix, or word-boundary match)
// in the channel name. This doesn't seem to be helpful in most cases,
// because it is hard for a query to be present in the name (the way
// mentioned before) and also present in the description.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird! Sounds like a bug.

Right now, this is useful in how far back from the cursor we look to
find a channel-link autocomplete (actually any autocomplete)
interaction in compose box.
In the following commits, this will be used as one of the criteria for
sorting channels in channel link autocomplete.
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from b654dd1 to be582cd Compare November 11, 2025 17:51
@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for the review. Changes pushed, PTAL.

@sm-sayedi sm-sayedi requested a review from gnprice November 11, 2025 18:05
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Comments below just on that revision / following up on my first review.

Comment on lines 401 to 403
for (final channelId in event.channelIds) {
final channel = streams.remove(channelId);
if (channel == null) break;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this condition shouldn't happen, right? It'd mean our state was out of sync with the server.

But if it does happen, it's best to carry on with removing any other channels that were in the list, so we don't get out of sync in a new way.

So two changes:

Suggested change
for (final channelId in event.channelIds) {
final channel = streams.remove(channelId);
if (channel == null) break;
for (final channelId in event.channelIds) {
final channel = streams.remove(channelId);
if (channel == null) continue; // TODO(log)

The TODO(log) comment is a marker for when we someday sweep through and add error reporting through Sentry or something like it, so that we can learn about unexpected conditions the client is hitting in the wild.

Comment on lines 1378 to 1379
// Check `typeahead_helper.compare_by_activity` in Zulip web;
// https://github.com/zulip/zulip/blob/c3fdee6ed/web/src/typeahead_helper.ts#L972-L988
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is part of the implementation comment, so goes inside the method body

(and really part of the same thought as the paragraph after it, which in this version is inside the method body)

Comment on lines 1416 to 1417
// shouldn't be possible — can only compose to one channel at a time
(true, true) => 0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way this would happen is if a and b are the same channel (same .streamId).

Which probably also shouldn't happen here — List.sort shouldn't bother calling its comparator to compare an element to itself, and the list shouldn't have the same channel twice. But that's a pretty nonlocal fact to convince oneself of, which is why this method should go ahead and do the right thing if it happens.

I think simplest to just not comment, and say _ => 0 for both this and the previous case together.

Comment on lines 1443 to 1448
static int compareByRecentActivity(ZulipStream a, ZulipStream b) {
// Compare `stream_list_sort.has_recent_activity` in Zulip web:
// https://github.com/zulip/zulip/blob/925ae0f9b/web/src/stream_list_sort.ts#L84-L96
// There are a few other criteria that Web considers for a channel being
// recently-active, for which we don't have all the data at the moment,
// e.g., whether the channel is newly subscribed.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. Can you write down the full list of differences? That's helpful (a) for convincing ourselves we're happy with not handling them now, and (b) for later coming back and filling them in.

For (a) in particular, writing down the list is helpful because the web app's code for autocomplete can get very tangly, and it often takes some work to pin down the full list of things it's considering and how they interact. So it'd be easy to miss something; and once you've done the work to pin down that full list, it's helpful to write it down so the work doesn't have to be repeated the next time you/we want to think about it again.

Comment on lines 1436 to 1460
static int compareByRecentActivity(ZulipStream a, ZulipStream b) {
return switch((a.isRecentlyActive, b.isRecentlyActive)) {
(true, false) => -1,
(false, true) => 1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to add those in this PR 🙂.

I'd like to have the complete list of differences in a comment in the code, though — or at least the full list of what you're aware of.

There are new changes made to `stream op: delete` event in server-10:
  - The `streams` field which used to be an array of the just-deleted
    channel objects is now an array of objects which only contains IDs
    of the just-deleted channels (the app throws away its event queue
    and reregisters before this commit).
  - The same `streams` field is also deprecated and will be removed in a
    future release.
  - As a replacement to `streams`, `stream_ids` is introduced which is
    an array of the just-deleted channels IDs.

Related CZO discussion:
  https://chat.zulip.org/#narrow/channel/378-api-design/topic/stream.20deletion.20events/near/2284969
So to call AutocompleteViewManager.unregisterEmojiAutocomplete.
…iews`

This set replaces the three sets of different `AutocompleteView`
subclasses, simplifying the code.
These two methods were introduced but never called.
Also, generalize the dartdoc of NameMatchQuality.

For almost all types of autocompletes, the matching mechanism/quality
to an autocomplete query seems to be the same with rare exceptions
(at the time of writing this —— 2025-11, only the emoji autocomplete
matching mechanism is different).
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and here's a review of the tests for that 10th commit:
eeb7ee9 autocomplete: Add view-model ChannelLinkAutocompleteView

I skipped some of the details in the "ranking across signals" tests; I'll look closer after some simplifications mentioned below.

Comment on lines 1463 to 1466
int compareAB(int a, int b, {required int composingToChannelId}) =>
ChannelLinkAutocompleteView.compareByComposingTo(
eg.stream(streamId: a), eg.stream(streamId: b),
composingToChannelId: composingToChannelId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name doesn't really fit, because the function doesn't compare some existing thing called "A" to an existing thing called "B" — it just compares its two arguments. So "compare" would be an accurate name.

Similarly the helpers below with the same name.


In some of the existing tests in this file, there are helpers with that name, like this one:

      int compareAB() => MentionAutocompleteView.compareByDms(
        eg.user(userId: idA),
        eg.user(userId: idB),
        store: store,
      );

But the point there is that the function's meaning is "compare user A with user B", where the helper and its callers have these shared locals:

      const idA = 1;
      const idB = 2;

that define the meaning of "user A" and "user B".

Comment on lines 1606 to 1607
group('ranking across signals', () {
store = eg.store();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates shared mutable state between the tests. That's a classic source of bugs that can be a pain to diagnose: a test passes when run after some other tests but fails when run alone, or vice versa.

In principle we run that risk already in a lot of our tests by having some shared variables, often called store and/or connection. But the key to making that pattern work is that every test case that uses the shared variables starts by resetting them all to fresh values. That way, whatever changes a previous test case might have made get wiped away and have no effect. As long as we stick to that pattern, we avoid truly having any shared mutable state between tests, and we're safe from that class of bugs.

That resetting from scratch is typically done via a helper function with a name like prepare. In turn, the shared variables are typically declared right next to the function that sets them; that helps make the relationship clear.

If you look back at the group "MentionAutocompleteView sorting results" above in this file, you'll see it's an example of that pattern.

});
});

group('ranking across signals', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's one more signal to test directly first: comparing by name.

That doesn't need to be a detailed test of all the nuances of comparing names (that's what tests for ChannelStore.compareChannelsByName would do) — just check that this code is comparing by name at all.

In particular when reading these across-signals tests, one needs to know that the name is one of the signals in order to understand what these tests are saying.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, then I think "compare by name" is included in "ranking across signals" tests with the comment "Runner-up by name". 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but it's easier to think about when there's a test which focuses on that aspect. The "ranking across signals" tests have a lot of other stuff going on.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh, so that means a standalone test for "compare by name". Ok, that make sense.

Comment on lines 1631 to 1633
final channels = [
// Wins by being the composing-to channel.
eg.stream(streamId: 1, name: 'Z'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to properly make the point, this needs to also be not recently active, and have zero (or null) weekly traffic.

Otherwise, it might be beating some of the other channels for one or more of those other reasons, rather than because it's the composing-to channel.

See the comment on the similar pattern above in this file:

      test('TopicNarrow: topic recency > stream recency > DM recency > human/bot > name', () async {
        // The user with the greatest topic recency ranks last on each of the
        // other criteria, but comes out first in the end, showing that
        // topic recency comes first.  Then among the remaining users, the one
        // with the greatest stream recency ranks last on each of the remaining
        // criteria, but comes out second in the end; and so on.

Comment on lines 1636 to 1639
// Runner-up by being unmuted pinned.
eg.subscription(eg.stream(streamId: 2, name: 'Y'), isMuted: false, pinToTop: true),
// Runner-up by being unmuted unpinned.
eg.subscription(eg.stream(streamId: 3, name: 'X'), isMuted: false, pinToTop: false),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, the first runner-up should be unmuted but have everything else working to rank it at the end (except in comparison to the winner). That way we can tell from reading the test that "unmuted" beats all the other criteria, except for "composing to".

(And we only need one unmuted channel in this list. The fact that pinned beats unpinned was covered in one of the test cases above.)

Comment on lines 1643 to 1647
eg.subscription(eg.stream(streamId: 5, name: 'V'), isMuted: true, pinToTop: false),

// The rest are runners-up by not being subscribed to.
// Runner-up by being recently active.
eg.stream(streamId: 6, name: 'U', isRecentlyActive: true),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need these channel/stream IDs? I think we can simplify by leaving them out.

In particular, it looks like the only one we actually use is the one for the composing-to channel. And we could get that one like channels[0].streamId.

Comment on lines 1677 to 1680
final channels = [
// Next four are runners-up by being subscribed to.
// Runner-up by being unmuted pinned.
eg.subscription(eg.stream(streamId: 1, name: 'Y'), isMuted: false, pinToTop: true),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is the winner, not the runner-up 🙂

});

group('ChannelLinkAutocompleteQuery', () {
test('testChannel: channel is included if name words match the query', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the corresponding tests for user mentions, there's this:

    test('user is always excluded when not active regardless of other criteria', () {

That gives me the thought that we should probably be doing something similar here: excluding channels with isArchived: true.

Does web lack that behavior? If so then that'd be another difference to note in the comment that compares our behavior to web's (similar to the existing example of such a comment which I mentioned earlier).

doCheck('Four C', eg.stream(name: 'Channel Name Four Words'), false);
});

group('ranking', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the end of this group, I'd like to also include the "full list of ranks" type of test case, similar to our tests for other types of autocomplete. It's helpful for seeing a global view of the ranking.

For this one it'll be quite short — that means we could do pretty OK without it, but also means it's cheap to include. And by including it, we'll help make sure that we include such a test case if we later extend this ranking and make it more complex, so that that test case becomes more necessary.

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from be582cd to accc796 Compare November 12, 2025 17:38
@sm-sayedi
Copy link
Collaborator Author

Thanks for the review. New revision pushed.

@sm-sayedi sm-sayedi requested a review from gnprice November 12, 2025 17:45
As of this commit, it's not yet possible in the app to initiate a
channel link autocomplete interaction. So in the widgets code that would
consume the results of such an interaction, we just throw for now,
leaving that to be implemented in a later commit.
…rseMarkedText

This was first added in 0886948, but seems to have been
accidentally removed in 046ceab.
This way, subclasses can use the reference to the store for different
purposes, such as using `max_topic_length` for the topic length instead
of the hard-coded limit of 60, or using `max_stream_name_length` for
how far back from the cursor we look to find a channel-link autocomplete
interaction in compose box.
For this commit we temporarily intercept the query at the
AutocompleteField widget, to avoid invoking the widgets that are
still unimplemented. That lets us defer those widgets' logic to a
separate later commit.
This will make it easy to use the fragment string in several other
places, such as in the next commits where we need to create a fallback
markdown link for a channel.
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from accc796 to 020c2bd Compare November 13, 2025 02:47
@sm-sayedi
Copy link
Collaborator Author

(Pushed with the test mentioned in #1902 (comment) included.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration review Added by maintainers when PR may be ready for integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants